第 17 天 - 在 HTML 模板中渲染動態內容
在第 17 天,我示範如何在元件中渲染動態內容。Vue 3 將內容投影到 slots
,並可選擇性地顯示插槽屬性 (slot props)。在 Svelte 5 中,slot 被 snippet 取代,而 render 標籤 (tag) 則是在模板中渲染片段 (fragment)。Angular 提供 ng-content
用於內容投影,並用 ng-template
建立可在 ng-container
中顯示的模板片段 (template fragment)。
在這篇部落格文章中,包含兩個內容投影 (content projection) 的範例。第一個範例會在滑鼠懸停時更新 Add Plan
按鈕的文字。第二個範例在 CoffeePlan
元件中渲染條件插槽,當選擇咖啡方案時,該方案會顯示所投影的圖標。
const hover = ref(false);
<button type="submit"
@mouseenter="hover = true"
@mouseleave="hover = false">
<slot name="btn" :hover="hover" />
</button>
在 AddCoffeePlan
元件中,於按鈕元素的子元素位置插入具名插槽。hover
是一個插槽屬性,會將 hover
參考的值傳回給 PlanPicker
元件。當發生 mouseenter
事件時,hover
參考為 true
;當發生 mouseleave
事件時,則為 false
。
PlanPicker
元件可以使用此插槽屬性 (slot prop) 值來投影按鈕文字。
<script>
import type { Snippet } from 'svelte';
interface Props {
addPlanButton: Snippet<[boolean]>;
}
const { addPlanButton }: Props = $props();
let hover = $state(false);
</script>
<button type="submit"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
{@render addPlanButton(hover)}
</button>
CoffeePlan
元件匯入 Snippet
類型以為 addPlanButton
屬性添加型別。addPlanButton
是一個接受 boolean
參數的片段 (fragment)。此外,hover
是一個 rune,在 mouseenter
事件時設為 true
,mouseleave 事件時設為 false
。
@render
標籤 (tag) 將該 rune 傳遞給 addPlanButton
片段並渲染它。
@Component({
...
})
export class CoffeePlanComponent {
hover = output<boolean>();
}
<button type="submit"
(mouseenter)="hover.emit(true)"
(mouseleave)="hover.emit(false)"
>
<ng-content />
</button>
我找不到 Angular 中與插槽屬性 (slot prop) 相當的概念。因此,我定義了一個自訂的 hover
事件,將其值發送給 PlanPicker
元件。
我也在按鈕元素中插入了 <ng-content>
,使 PlanPicker
元件能夠投影 Add Plan
按鈕的文字。
<AddCoffeePlan>
<template #btn="{ hover }">
Add Plan {{ hover ? '(+1)' : '' }}
</template>
</AddCoffeePlan>
<AddCoffeePlan>
元件的主體中有一個名為 #btn
的模板。從插槽 (slot) 屬性解構出 hover
屬性。當 hover
為 true 時,按鈕文字為 Add Plan (+1)
,否則文字為 Add Plan
。
<AddCoffeePlan>
{#snippet addPlanButton(hover: boolean)}
Add Plan {hover ? '(+1)' : ''}
{/snippet}
</AddCoffeePlan>
addPlanButton
片段 (snippet) 宣告在 AddCoffeePlan
元件內,因此它是該元件的隱含屬性 (implicit prop)。
同樣地,當 hover
參數為 true
時,按鈕文字為 Add Plan (+1)
;否則文字為 Add Plan
。
<app-add-coffee-plan (hover)="hover.set($event)">
{{ addPlanText() }}
</app-add-coffee-plan>
export class PlanPickerComponent {
hover = signal(false);
addPlanText = computed(() => `Add Plan ${this.hover() ? '(+1)' : ''}`);
}
hover
自訂事件會發出一個 boolean 值,直接設定 hover
信號。
接著,addPlanText
計算屬性根據 hover
信號的值決定按鈕文字。
在 <app-add-coffee-plan>
元件的主體中,呼叫 addPlanText
getter,並將文字投影到新增方案按鈕上。
當選擇 coffee plan 且方案名稱以 'The' 開頭時,會投影 coffee
和coffee maker
圖標。當選擇的方案名稱不以 'The' 開頭時,會投影 tea
和 burger
圖標。未選擇的方案則不顯示任何圖標。
安裝圖標函式庫
npm install --save-dev @iconify/vue
npm install --save-dev @iconify/svelte
npm install @ng-icons/core @ng-icons/material-icons
建立模板以渲染不同圖標
Vue 3 Application
import { Icon } from '@iconify/vue';
<CoffeePlan v-for="plan in plans">
<template #coffee v-if="isSelected(plan) && plan.startsWith('The')">
<div class="coffee">
<Icon class="icon" v-for="icon in ['ic:outline-coffee', 'ic:outline-coffee-maker']"
:key="icon" :icon="icon" />
</div>
</template>
<template #beverage v-if="isSelected(plan) && !plan.startsWith('The')">
<div class="beverage">
<Icon class="icon" v-for="icon in ['ic:outline-emoji-food-beverage', 'ic:outline-fastfood']" :key="icon" :icon="icon" />
</div>
</template>
</CoffeePlan>
在 PlanPicker
元件中建立了兩個具名模板。當所選方案以 'The' 開頭時,會渲染 coffee
模板。該模板顯示 coffee
和 coffee maker
圖標。不以 'The' 開頭的方案則顯示 beverage
模板。beverage
模板渲染 tea
和 burger
圖標。
coffee
模板中的圖標尺寸設為 48px。beverage
模板中的圖標較小且為藍色,尺寸為 42px。
import Icon from "@iconify/svelte";
{#snippet selectedPlanIcons()}
<div class="coffee">
{#each ['ic:outline-coffee', 'ic:outline-coffee-maker'] as name (name)}
<Icon icon={name} width="48" height="48" />
{/each}
</div>
{/snippet}
{#snippet selectedPlanBeverageIcons()}
<div class="beverage">
{#each ['ic:outline-emoji-food-beverage', 'ic:outline-fastfood'] as name (name)}
<Icon icon={name} width="42" height="42" color="blue" />
{/each}
</div>
{/snippet}
同樣地,selectedPlanIcons
片段渲染 coffee
和 coffee maker
圖標,而 selectedPlanBeverageIcons
片段則渲染 tea
和 burger
圖標。
{#each plans as plan (plan)}
{#if isSelected(plan)}
<CoffeePlan
selectedPlanBeverageIcons={!plan.startsWith('The') ? selectedPlanBeverageIcons : undefined}
selectedPlanIcons={plan.startsWith('The') ? selectedPlanIcons : undefined} />
{:else}
<CoffeePlan name={plan} {selectedPlan} selected={isSelected(plan)} />
{/if}
{/each}
這些片段作為屬性 (props) 傳遞給 CoffeePlan
元件。當所選方案名稱不以 'The' 開頭時,selectedPlanBeverageIcons
屬性會接收 selectedPlanBeverageIcons
模板,否則該屬性為未定義 (undefined)。當名稱以 'The' 開頭時,selectedPlanIcons
屬性會接收 selectedPlanIcons
模板,否則該屬性為未定義。
Angular 的 NgTemplateOutlet
更適合此使用情境。PlanPicker
元件建立了兩個模板片段,coffee
和 beverage
,分別渲染不同的圖標。這些片段作為信號輸入傳遞給 CoffeePlan
元件。
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
matCoffeeMakerOutline,
matCoffeeOutline,
matEmojiFoodBeverageOutline,
matFastfoodOutline,
} from '@ng-icons/material-icons/outline';
@Component({
selector: 'app-plan-picker',
imports: [NgIcon],
viewProviders: [
provideIcons({ matCoffeeMakerOutline, matCoffeeOutline, matEmojiFoodBeverageOutline, matFastfoodOutline }),
],
})
export class PlanPickerComponent {}
PlanPicker
元件從 ng-icons
和 ng-icons/material-icons
函式庫匯入類別和 SVG 圖標。
<ng-template #coffee>
<div class="coffee">
@for (iconName of ['matCoffeeOutline', 'matCoffeeMakerOutline']; track iconName) {
<ng-icon class="icon" [name]="iconName" />
}
</div>
</ng-template>
.coffee {
flex: display;
align-items: center;
> .icon {
width: 48px;
height: 48px;
color: brown;
}
}
此模板片段有一個模板變數 coffee
。它渲染 coffee
和 coffee maker
圖標,並使用簡單的 CSS 類別調整其尺寸和顏色。
<ng-template #beverage>
<div class="beverage">
@for (iconName of ['matEmojiFoodBeverageOutline', 'matFastfoodOutline']; track iconName) {
<ng-icon class="icon" [name]="iconName" />
}
</div>
</ng-template>
.beverage {
display: flex;
flex-direction: column;
padding: 0.25rem;
> .icon {
width: 42px;
height: 42px;
color: green;
}
}
beverage
模板片段會渲染 tea
和 burger
圖標,並使用 CSS 類別使其呈現藍色且大小為 42px。
@for (plan of plans(); track plan) {
@let coffeeTemplate = isSelected && plan.startsWith('The') ? coffee : undefined;
@let beverageTemplate = isSelected && !plan.startsWith('The') ? beverage : undefined;
<app-coffee-plan
[coffeeTemplate]="coffeeTemplate"
[beverageTemplate]="beverageTemplate"
/>
}
當所選方案名稱以 'The' 開頭時, coffeeTemplate
輸入會接收 coffee
模板片段。當所選方案名稱不符合 'The' 的條件時, beverageTemplate
輸入會接收 beverage
模板片段。
<template v-if="$slots.coffee">
<slot name="coffee" />
</template>
<div class="description">
<span class="title"> {{ name }} </span>
</div>
<template v-if="$slots.beverage">
<slot name="beverage" />
</template>
當 $slots.coffee
為 true
時,PlanPicker
元件會將 coffee
模板投影到 coffee
插槽。圖標顯示在描述的左側。
當 $slots.beverage
為 true
時,PlanPicker
元件會將 beverage
模板投影到 beverage
插槽。圖標顯示在描述的右側。
interface Props {
selectedPlanIcons?: Snippet;
selectedPlanBeverageIcons?: Snippet;
}
let {
name = 'Default Plan',
selectedPlan,
selected,
selectedPlanIcons,
selectedPlanBeverageIcons
}: Props = $props();
在 CoffeePlan
元件中,於 Props
介面新增可自選的 selectedPlanIcons
和 selectedPlanBeverageIcons
片段。
接著,selectedPlanIcons
和 selectedPlanBeverageIcons
從元件的屬性中解構出來。
<div>
{@render selectedPlanIcons?.()}
<div class="description">
<span class="title"> {name} </span>
</div>
{@render selectedPlanBeverageIcons?.()}
</div>
render
標籤 (tag) 會在描述的左側渲染可自選的 selectedPlanIcons
片段。可自選的 selectedPlanBeverageIcons
片段則渲染在描述的右側。
@Component({
selector: 'app-coffee-plan',
imports: [NgTemplateOutlet],
})
export class CoffeePlanComponent {
coffeeTemplate = input<TemplateRef<any> | undefined>(undefined);
beverageTemplate = input<TemplateRef<any> | undefined>(undefined);
}
CoffeePlan
元件匯入了 NgTemplateOutlet
以使用該指令 (directive)。coffeeTemplate
和 beverageTemplate
是可自選的模板參考,指向 PlanPicker
元件中的片段。
<ng-container [ngTemplateOutlet]="coffeeTemplate()" />
<div class="description">
<span class="title"> {{ name() }} </span>
</div>
<ng-container [ngTemplateOutlet]="beverageTemplate()" />
ngTemplateOutlet
是 NgTemplateOutlet
指令的一個輸入。它接受一個模板參考並顯示 ng-template
的內容。
第一個 ng-container
嵌入了 coffee
模板,第二個 ng-container
嵌入了 beverage
模板。咖啡圖標渲染在描述的左側,飲料圖標渲染在右側。
當 ngTemplateOutlet
輸入為未定義 (undefined) 時,將不會渲染任何內容。
我們已成功在 CoffeePlan
元件中進行內容投影 (content projection) 並渲染條件插槽 (conditional slots)。Vue 3 使用插槽來顯示可重複使用的模板。Svelte 5 引入 snippet 和 render 來達成相同的目的。Angular 提供 ngContent
用於投影,並使用 ngTemplate
創建模板片段,將動態內容嵌入 ngContainer
。